Перейти к основному содержимому

5.18. Типы данных

Разработчику Архитектору

Типы данных

Иерархия типов

В основе системы типов Scala лежит единая корневая точка — тип Any. Все остальные типы являются его подтипами. Это означает, что любое значение в Scala, будь то число, строка, функция или объект, совместимо с типом Any.

Тип Any разделяется на две основные ветви: AnyVal и AnyRef.

  • AnyVal представляет собой базовый тип для всех значений примитивных типов. Эти типы не являются полноценными объектами в смысле классической объектной модели, а скорее соответствуют машинным представлениям данных, таким как целые числа, символы или логические значения. Компилятор Scala стремится отображать их напрямую на соответствующие примитивы JVM (Java Virtual Machine), что делает работу с ними быстрой и эффективной по памяти.

  • AnyRef является базовым типом для всех ссылочных типов. Он эквивалентен типу java.lang.Object в Java. Любой пользовательский класс, массив, строка или функция в Scala наследуется от AnyRef. Это означает, что все такие значения хранятся в куче (heap) и передаются по ссылке.

Такая двойственность позволяет Scala сочетать эффективность работы с примитивами и гибкость объектной модели без необходимости в специальных обёртках, как это реализовано в некоторых других языках.


Примитивные типы (AnyVal)

Класс AnyVal имеет девять прямых подтипов, каждый из которых соответствует конкретному примитивному типу:

  • Double — 64-битное число с плавающей запятой, соответствует double в Java.
  • Float — 32-битное число с плавающей запятой, соответствует float в Java.
  • Long — 64-битное целое число со знаком, соответствует long в Java.
  • Int — 32-битное целое число со знаком, соответствует int в Java.
  • Short — 16-битное целое число со знаком, соответствует short в Java.
  • Byte — 8-битное целое число со знаком, соответствует byte в Java.
  • Char — 16-битный символ в кодировке UTF-16, соответствует char в Java.
  • Boolean — логическое значение, принимающее одно из двух состояний: true или false.
  • Unit — специальный тип, имеющий ровно одно значение, обозначаемое как (). Он используется для обозначения отсутствия полезного результата, аналогично void в других языках. Однако в отличие от void, Unit является полноценным типом, и его значение может быть использовано в выражениях.

Эти типы не требуют явного создания через оператор new. Они создаются автоматически при присваивании литералов или выполнении операций. Например, запись val x = 42 автоматически выводит тип x как Int.


Ссылочные типы (AnyRef)

Все типы, не входящие в AnyVal, являются подтипами AnyRef. К ним относятся:

  • Строки (String) — неизменяемые последовательности символов, унаследованные от Java.
  • Массивы (Array[T]) — контейнеры фиксированного размера, индексируемые целыми числами.
  • Функции — в Scala функции являются объектами первого класса. Они представлены трейтами Function0, Function1, ..., Function22, где цифра указывает максимальное количество параметров, поддерживаемых стандартной библиотекой.
  • Пользовательские классы и трейты — любые определённые программистом структуры данных, включая case-классы, объекты-компаньоны, абстрактные классы и т. д.

Все эти типы имеют общие методы, унаследованные от AnyRef, такие как equals, hashCode, toString, getClass, а также методы для синхронизации: wait, notify, notifyAll.


Тип Nothing и Null

В дополнение к основной иерархии, Scala включает два специальных типа, которые находятся в нижней части дерева наследования:

  • Nothing — это подтип любого другого типа. Он не имеет значений. Тип Nothing используется для обозначения выражений, которые никогда не возвращают управление, например, вызовов, выбрасывающих исключение или завершающих программу. Поскольку Nothing является подтипом всех типов, его можно использовать в любом контексте, где ожидается значение, но фактически такого значения не будет. Это позволяет сохранять типовую согласованность даже в ситуациях аварийного завершения.

  • Null — это подтип всех ссылочных типов (AnyRef), но не примитивных (AnyVal). Он имеет единственное значение — null. Этот тип существует в основном для совместимости с Java, где null широко используется. В чистом Scala-коде рекомендуется избегать null в пользу более безопасных альтернатив, таких как Option.


Параметризованные типы

Scala поддерживает обобщённое программирование через параметризованные типы. Это позволяет создавать структуры данных и алгоритмы, работающие с произвольными типами, сохраняя при этом строгую типизацию. Например, тип List[Int] обозначает список целых чисел, а List[String] — список строк. Компилятор гарантирует, что в список целых нельзя случайно поместить строку.

Параметризация применяется не только к коллекциям, но и к функциям, классам, трейтам. Она лежит в основе многих идиоматических конструкций Scala, таких как Option[T], Either[L, R], Try[T], которые используются для безопасной обработки неопределённых или ошибочных ситуаций.


Типы-объединения и пересечения (Union и Intersection Types)

Начиная с версии Scala 3, язык получил встроенную поддержку типов-объединений (A | B) и типов-пересечений (A & B).

  • Тип-объединение A | B означает, что значение может быть либо типа A, либо типа B. Это мощный инструмент для описания вариативности без необходимости вводить общий супертип или использовать наследование. Например, функция может принимать аргумент типа String | Int, и внутри неё можно проверить реальный тип с помощью сопоставления с образцом (pattern matching).

  • Тип-пересечение A & B означает, что значение одновременно удовлетворяет обоим типам A и B. Это полезно, когда требуется комбинировать поведение нескольких трейтов или интерфейсов. Например, если есть трейты Readable и Writable, то тип Readable & Writable описывает объект, который поддерживает оба этих контракта.

Эти типы расширяют выразительность системы типов, позволяя формулировать более точные и гибкие контракты между компонентами программы.


Типы высших порядков

Scala поддерживает типы высших порядков — типы, параметризованные другими типами. Например, List сам по себе не является конкретным типом, а представляет собой конструктор типов, который становится конкретным только после применения к параметру, например, List[Int].

Это позволяет писать абстрактные алгоритмы, работающие с любыми контейнерами, удовлетворяющими определённым требованиям. Такие возможности активно используются в библиотеках, реализующих функциональные паттерны, такие как Functor, Monad, Applicative.


Вывод типов

Одной из важных особенностей Scala является мощная система вывода типов. Программисту часто не требуется указывать тип явно — компилятор способен определить его автоматически на основе контекста. Например, в выражении val name = "Scala" компилятор выводит тип name как String. Это уменьшает многословность кода, сохраняя при этом всю строгость статической типизации.

Вывод типов работает не только для простых значений, но и для сложных выражений, включая функции, цепочки вызовов и параметризованные типы. Однако в некоторых случаях, особенно при работе с обобщёнными структурам данными или неоднозначными контекстами, явное указание типа остаётся необходимым для разрешения неопределённости.


Пользовательские типы и case-классы

В Scala программист может определять собственные типы с помощью ключевых слов class, case class, object и trait. Эти конструкции позволяют моделировать сложные доменные понятия с высокой точностью и выразительностью.

Особое место среди пользовательских типов занимают case-классы. Они предназначены для представления неизменяемых структур данных и автоматически предоставляют набор полезных методов:

  • Все поля объявляются как val по умолчанию, то есть неизменяемы.
  • Компилятор генерирует реализации методов equals, hashCode и toString, что делает сравнение и отладку интуитивными.
  • Поддерживается сопоставление с образцом (pattern matching), что позволяет декомпозировать объект на составляющие части.
  • Автоматически создаётся объект-компаньон с методом apply, позволяющим создавать экземпляры без ключевого слова new.

Пример:

case class Person(name: String, age: Int)
val p = Person("Alice", 30) // эквивалентно Person.apply("Alice", 30)

Case-классы идеально подходят для функционального стиля, где данные передаются явно, а изменение состояния заменяется созданием новых значений.


Алгебраические типы данных (ADT)

Алгебраические типы данных — это мощный способ моделирования всех возможных состояний программы. В Scala они строятся с использованием sealed-иерархий и case-классов (или case-объектов для единичных значений).

Ключевая идея: если все подтипы перечислены в одном файле с помощью модификатора sealed, компилятор может проверить, что при сопоставлении с образцом обработаны все возможные случаи. Это исключает ошибки времени выполнения из-за неполного анализа вариантов.

Пример:

sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case object Empty extends Shape

Такая иерархия описывает форму как либо круг, либо прямоугольник, либо пустую фигуру. Любой код, работающий с Shape, обязан учитывать все три варианта. Компилятор предупредит, если какой-то случай пропущен.

Этот подход особенно ценен при проектировании бизнес-логики, где важно явно выразить все возможные состояния системы: например, «заказ создан», «оплачен», «отменён», «доставлен».


Обработка ошибок через типы

Scala отказывается от традиционных исключений в пользу типобезопасных обёрток. Это делает ошибки частью сигнатуры функции и заставляет вызывающую сторону обрабатывать их явно.

Основные типы для обработки ошибок:

  • Option[T] — представляет значение, которое может отсутствовать. Имеет два подтипа: Some(value) и None. Используется, когда результат операции не гарантирован, но причина отсутствия значения не важна.

  • Either[L, R] — представляет значение, которое может быть одного из двух типов. По соглашению, Left используется для ошибок, а Right — для успешного результата. Это позволяет не только фиксировать факт ошибки, но и передавать её описание.

  • Try[T] — обёртка над вычислением, которое может выбросить исключение. Имеет два подтипа: Success(value) и Failure(exception). Используется при взаимодействии с Java-кодом или внешними системами, где исключения неизбежны.

Эти типы поддерживают функциональные операции: map, flatMap, fold, что позволяет строить цепочки преобразований без явных условных конструкций.

Пример:

def divide(a: Int, b: Int): Option[Int] =
if (b != 0) Some(a / b) else None

val result = divide(10, 2).map(_ * 3) // Some(15)

Такой стиль повышает надёжность и читаемость: каждый шаг цепочки честно заявляет о возможности отсутствия результата.


Функции как типы

В Scala функции являются полноценными значениями и имеют собственные типы. Тип функции записывается как A => B, что означает «функция, принимающая аргумент типа A и возвращающая значение типа B».

Под капотом функция — это экземпляр трейта FunctionN, где N — количество параметров. Например, Int => String — это сокращение от Function1[Int, String].

Функции можно передавать как аргументы, возвращать из других функций, сохранять в переменных и коллекциях. Это лежит в основе функционального программирования и позволяет писать высокоабстрактный, переиспользуемый код.

Пример:

val greet: String => String = name => s"Hello, $name!"
val messages = List("Alice", "Bob").map(greet) // List("Hello, Alice!", "Hello, Bob!")

Типизация функций обеспечивает безопасность: невозможно передать функцию с несовместимой сигнатурой, и компилятор проверит это заранее.


Типы и композиция

Scala поощряет композицию вместо наследования. Трейты (trait) служат основным механизмом для совместного использования поведения. Они могут содержать как абстрактные, так и конкретные методы, а также типовые параметры и вложенные типы.

С помощью mixin-композиции можно комбинировать несколько трейтов в одном объекте, создавая гибкие и адаптивные структуры. Это особенно полезно при тестировании: зависимости легко заменяются моками без изменения основной логики.

Пример:

trait Logger {
def log(msg: String): Unit
}

trait TimestampLogger extends Logger {
override def log(msg: String): Unit = super.log(s"[${System.currentTimeMillis()}] $msg")
}

class ConsoleLogger extends Logger {
def log(msg: String): Unit = println(msg)
}

val logger = new ConsoleLogger with TimestampLogger
logger.log("System started") // [1705987200000] System started

Такой подход позволяет строить системы из маленьких, тестируемых и переиспользуемых компонентов.


Вариантность (Variance)

Вариантность определяет, как параметризованные типы реагируют на отношения между их аргументами. В Scala это управляется аннотациями + (ковариантность) и - (контравариантность).

  • Ковариантный тип List[+A] означает: если Cat — подтип Animal, то List[Cat] является подтипом List[Animal]. Это логично для неизменяемых структур: список кошек можно безопасно использовать там, где ожидается список животных.

  • Контравариантный тип Function1[-A, +B] означает: если Cat — подтип Animal, то Function1[Animal, String] является подтипом Function1[Cat, String]. Это связано с тем, что функция, принимающая любое животное, может быть использована вместо функции, ожидающей только кошку — она более общая.

  • Инвариантный тип (без аннотаций) требует точного совпадения типов. Например, Array[A] инвариантен, потому что массивы изменяемы: запись Dog в Array[Cat], приведённый к Array[Animal], нарушит безопасность.

Аннотации вариантности задаются при объявлении типа и строго проверяются компилятором. Нарушение правил вариантности приводит к ошибке компиляции, что предотвращает опасные преобразования.


Зависимые типы и тайп-классы

Scala поддерживает выразительные механизмы, выходящие за рамки простой параметризации. Хотя полноценные зависимые типы (где тип зависит от значения) реализованы в ограниченной форме, язык предоставляет мощные инструменты для типобезопасного абстрагирования.

Тайп-классы — это паттерн, позволяющий добавлять поведение к существующим типам без изменения их исходного кода. Они реализуются через трейты и неявные (implicit) параметры или, в Scala 3, через ключевое слово given.

Пример:

trait Show[T] {
def show(value: T): String
}

given Show[Int] with {
def show(value: Int): String = s"Number: $value"
}

def display[T](value: T)(using s: Show[T]): String = s.show(value)

Здесь Show — тайп-класс, описывающий способ представления значения в виде строки. Для Int предоставляется конкретная реализация. Функция display работает с любым типом, для которого существует экземпляр Show.

Этот подход лежит в основе многих библиотек: сериализация, сравнение, математические операции. Он обеспечивает расширяемость без наследования и позволяет избегать «божественных» интерфейсов.


Метапрограммирование и типы во время компиляции

Scala предоставляет средства для выполнения вычислений на этапе компиляции, что позволяет генерировать код, проверять инварианты и оптимизировать производительность.

В Scala 2 это достигалось с помощью макросов, но в Scala 3 макросы заменены на inline-функции и quoted expressions. Эти механизмы позволяют анализировать и модифицировать AST (абстрактное синтаксическое дерево) программы, сохраняя при этом полную типовую безопасность.

Пример использования — автоматическая генерация кода для сериализации:

inline def deriveCodec[T]: Codec[T] = ${ CodecMacros.deriveCodecImpl[T] }

Макрос получает тип T во время компиляции, анализирует его структуру (например, поля case-класса) и генерирует эффективный сериализатор без рантайм-рефлексии.

Такой подход снижает накладные расходы, устраняет классовые ошибки и делает библиотеки более удобными: программист не пишет шаблонный код, а компилятор делает это за него.


Роль типов в современных библиотеках

Современные библиотеки Scala активно используют систему типов для выражения семантики:

  • ZIO моделирует эффекты как параметризованные типы ZIO[R, E, A], где R — окружение, E — возможная ошибка, A — успешный результат. Это позволяет точно описывать зависимости, побочные эффекты и обработку ошибок.

  • Cats и Shapeless предоставляют универсальные абстракции (Functor, Monad, Generic) и инструменты для работы с типами на уровне компиляции.

  • fs2 и Akka Streams используют типы для описания потоков данных, гарантируя, что операции над потоками составлены корректно.

В этих библиотеках типы служат не только для проверки, но и для документирования: сигнатура функции часто полностью раскрывает её поведение.


Типы как документация

Одним из важнейших свойств строгой типизации является её способность заменять комментарии. Хорошо спроектированный тип сам по себе объясняет, что делает функция и какие данные она ожидает.

Например, функция с сигнатурой:

def process(input: List[Order], validator: Order => Option[ValidationError]): List[Either[ValidationError, ProcessedOrder]]

чётко сообщает:

  • принимает список заказов,
  • использует валидатор, который может вернуть ошибку,
  • возвращает список результатов, каждый из которых либо ошибка, либо обработанный заказ.

Такой код не требует дополнительных пояснений. Типы становятся исполняемой спецификацией.